查看原文
其他

嵌入Python解释器的程序逆向

mb_fefksfsl 看雪学苑 2024-07-16




前言


最近在玩一款单机游戏,想试着改一下里面各类数据,却发现生命值、金币等数据都没办法修改。往里面一看,发现这游戏居然在完全单机的情况下,都会往一个本地的 Python 服务器发包。重要的逻辑都在这个服务器里面处理了。

这个 Python 服务器是以 DLL 的形式作为 Unity 插件引入的。DLL 导出了服务器初始化、发包、收包的函数,由 GameAssembly.dll 那边直接调用。

查了一下网上的信息,据说这款游戏之前逻辑是放客户端的,后来就一直在往这个 Python 服务器里挪,还把客户端的各种调试方法都删了。这可能也是为什么客户端里有一个类的方法通过 il2cpp dumper 出来后偏移都一样的原因。这个类包含了很多看名字就觉得很有用的调试方法,例如应用伤害等;可惜都是空的,ida里看这个地址就是 retn 0;






为什么无法搜索内存改值?


细心的小伙伴可能会问了,虽然这是服务器,但它也在程序的进程内,内存空间是在一起的,怎么会没有办法修改呢?

简单的说,就是 Python 的值打一枪换一个地方,所以 CE 这种搜固定内存的变化的方法是很难直接找到对应的值进行修改的。你可以自己启动 Python,输入 a=1234, 用 CE 搜索,再输入 a = a+1,再用 CE 搜索,是搜不到任何对应内存的。

下面我用一段官方的示例代码(https://docs.python.org/3/c-api/intro.html#coding-standards),来说明一下。这段 Python 代码和 C 代码是等价的,用于将 dict[key] 自增 1.

def incr_item(dict, key):
try:
item = dict[key]
except KeyError:
item = 0
dict[key] = item + 1
int
incr_item(PyObject *dict, PyObject *key)
{
/* Objects all initialized to NULL for Py_XDECREF */
PyObject *item = NULL, *const_one = NULL, *incremented_item = NULL;
int rv = -1; /* Return value initialized to -1 (failure) */

item = PyObject_GetItem(dict, key);
if (item == NULL) {
/* Handle KeyError only: */
if (!PyErr_ExceptionMatches(PyExc_KeyError))
goto error;

/* Clear the error and use zero: */
PyErr_Clear();
item = PyLong_FromLong(0L);
if (item == NULL)
goto error;
}
const_one = PyLong_FromLong(1L);
if (const_one == NULL)
goto error;

incremented_item = PyNumber_Add(item, const_one);
if (incremented_item == NULL)
goto error;

if (PyObject_SetItem(dict, key, incremented_item) < 0)
goto error;
rv = 0; /* Success */
/* Continue with cleanup code */

error:
/* Cleanup code, shared by success and failure path */

/* Use Py_XDECREF() to ignore NULL references */
Py_XDECREF(item);
Py_XDECREF(const_one);
Py_XDECREF(incremented_item);

return rv; /* -1 for error, 0 for success */
}

可以看到 Python 底层对象都是 PyObject。存储很喜欢 dict 这种形式。每个类的函数、成员都是用 dict 存的。Hook 对 dict 的操作就能够监视到很多信息。

关注其中
incremented_item = PyNumber_Add(item, const_one);在获取到item后,并不会直接对item内部的内存进行自增,而是调用函数进行加法,创建了一个新的对象incremented_item,然后再PyObject_SetItem(dict, key, incremented_item)换回去。所以两个值在内存上并不会是同一个地址。

另外,注意其中
Py_XDECREF(item);这是减少一次引用计数的意思。python 底层实现里 PyObject 都是有引用计数的。这也意味着我们如果直接修改内存中的值,会同时修改所有使用这个对象的地方。

所以,最好的方法还是在 Python 解释器以及前面的字节码这部分把问题解决掉,而不是在内存里解决。






通过 hook 解释器底层函数来修改值


那就来逆这个 Python 服务器吧。ida 搜索到大量 Python 相关的字符串,鉴定为 Cpython-36。



弄一份 Cpython-36 的源码,同时安装一下可执行文件。源码链接(https://www.python.org/downloads/release/python-368/)

先试着用下载下来的带符号 python36.dll 的特征码搜索,发现搜不到。这可能是因为 build 版本不同,还可能是因为这个嵌入的 python 解释器是从源码编译的。

没法直接搜特征,所以只能对照着源码的字符串,去定位一些关键函数。这个步骤就和做数独一样。

个人感觉hook后能够获得大量信息的函数有
PyUnicode_FromString, _PyObject_GenericGetAttrWithDict, PyObject_SetItem, PyObject_Call。还有一些有用的辅助函数有PyObject_Repr, PyUnicode_AsUTF8, PyGILState_Ensure, PyGILState_Release
找到这些函数的地址后,写一个 dll 来进行 hook。可以 include 一下
Python36-x64\include\Python.h,虽然不能直接用自己这个 Python 解释器里的函数,但是头文件里很多宏是对对象直接操作的,还是比较有用的。

hook 之后如何获取运行信息呢?PyUnicode_FromString 的参数就是 const char * ,直接打印就好。但是其他几个参数都是 PyObject,所以我们需要把 PyObject转为可读的字符串,以便进行进一步的分析。

利用嵌入的 Python 解释器自己的 PyObject_Repr 和 PyUnicode_AsUTF8 就可以获得可读信息。下面是我用的代码。

函数地址是嵌入的 Python 解释器对应函数的地址。有些对象可以直接用自己下载的那个解释器的函数,应该是字符串对象这种不需要执行具体指令的对象。但是其他对象很容易崩。

string GetPyObjectString(void* obj)
{
//auto gstate = oPyGILState_Ensure();

//auto pyobj = reinterpret_cast<PyObject*>(obj);
auto str_obj = oPyObject_Repr(obj);
if (!str_obj) {
PyErr_Print();
//oPyGILState_Release(gstate);
return {};
}
const char* str = oPyUnicode_AsUTF8(str_obj);
if (!str) {
PyErr_Print();
Py_DECREF(str_obj);
//oPyGILState_Release(gstate);
return {};
}
string ret{ str };
Py_DECREF(str_obj);
//oPyGILState_Release(gstate);
return ret;
}

尽管这样,对于一些对象还是会在 oPyObject_Repr 里似乎是死锁崩溃。对 Python 对象操作前据说要先拿全局锁,但我试着用 oPyGILState_Ensure 获取 Python C API 全局锁,并没有解决崩溃的问题。

因此,有些对象不方便用这种方法获取可读信息,就可以只获取其类型信息,用
string obj_class = Py_TYPE(obj)->tp_name;。同时,尽量减少查看的类型,过滤不感兴趣的调用可以有效减少崩溃。

下面是对 _PyObject_GenericGetAttrWithDict 的 Hook 示例。

void* My_PyObject_GenericGetAttrWithDict(void* obj, void* name, void* dict) {
static unordered_set<string> intersted_name{
R"#('m_HP')#",
};
ostringstream oss;
auto name_str = GetPyObjectString(name);
if (intersted_name.find(name_str) == intersted_name.end()) {
oss << "Skip: "<<name_str;
LogMsg(oss.str());
return o_PyObject_GenericGetAttrWithDict(obj, name, dict);;
}
string obj_class = Py_TYPE(obj)->tp_name;
oss << "_PyObject_GenericGetAttrWithDict: " << endl
<< '\t' << obj <<":" << obj_class << endl
<< '\t' << name << ":" << name_str << endl
<< '\t' << dict << endl;
// 避免被回收,提前解析字符串
auto ret = o_PyObject_GenericGetAttrWithDict(obj, name, dict);
auto dict_str = GetPyObjectString(ret);
auto t = Py_TYPE(ret);
oss << '\t' << " -> " << ret << " Type: " << t->tp_name << " Value: " << dict_str;
if (obj_class == "Player") {
if (name_str == R"('m_HP')") {
((PyLongObject*)ret)->ob_digit[0] = 78900; // 血量
oss << " hijked -> " << ret << Py_TYPE(ret)->tp_name << " : " << GetPyObjectString(ret);
}
}

LogMsg(oss.str());
return ret;
}

这个函数里,检测到获取 Player 类的 m_HP 变量时,就将变量存储的值修改为想要的值。示例中是通过打印信息提前知道了返回值在这种情况下是 int,所以用((PyLongObject*)ret)->ob_digit[0] = 10000;设置。

这样直接改存储值其实不好,一方面所有用同一个对象的地方都会被修改,一方面存储0这样的常量的地方是改不了。更好的做法是像 Python 那样新建对象、设置对象、减少引用。但无所谓,现在
玩家的 m_HP 已经在 Python 解释器层面无法被减少了。






改值之外?


只是改值满足不了我,我还想进行更多修改,怎么办?

PyObject_Call 能够监听很多东西。看日志发现程序导入 zlib,从一个特殊格式的文件里读入信息,并进行解压。解压后立即进行了模块导入。看解压的内容,'\33\r\r\n'刚好是 Python36 字节码的 MaigcNumber。可以推断这是实际运行的业务代码。类名、函数名都是能够从字节码里还原出来的。



然而,尝试了python-uncompyle6(https://github.com/rocky/python-uncompyle6),以及 python3.6里直接 dis.dis(data[16:]) 后,都失败了。

根据日志的 PyObject_Call 看到嵌入的解释器能够直接导入该字节码的。

一方面可以试试纯用 Python C API 接口,例如
PyObject_Dir在嵌入的解释器里查,但是这样逆向写起来实在算不上方便;
一方面说不定能够用PyRun_InteractiveLoop调出交互命令行来,这样应该更加方便。

还有纯逆向直接调用 PyObject_Call,但是想想都觉得很麻烦。

如果接下来想要获得更大的自由度,应该考虑看一看 Python 字节码层面。直接摆弄解释器还是太底层了。




结论


本文介绍了一种 Hook Python 解释器底层函数进行逆向分析的方法,在解释器层面实现了获取特定值的劫持。这对于嵌入 Python 或者魔改了解释器的 Python 程序分析有一定的帮助。但个人感觉有点弄复杂了,希望各位大佬指教。




看雪ID:mb_fefksfsl

https://bbs.kanxue.com/user-home-979936.htm

*本文为看雪论坛优秀文章,由 mb_fefksfsl 原创,转载请注明来自看雪社区



# 往期推荐

1、记由长城杯初赛Time_Machine掌握父子进程并出题

2、从Clang到Pass加载与执行的流程

3、OLLVM混淆源码解读

4、VMProtect保护壳爆破步骤详解(入门级)

5、Attitude Adjustment -- Fast Quaternion Attitude



球分享

球点赞

球在看



点击阅读原文查看更多

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存